在 x86 BIOS 環境保留了固定的地址作為畫面顯示使用(0Xb8000) 則對應彩色的文本寫入 (80 * 25),而 0xA0000 則用於圖形模式顯式。而現代的設備的 framebuffer 地址會透過 VBE/UEFI 提供另外這個地址式可配置的,不過為了簡單起見這裡我們先寫死,先實現 0xB8000 的文本顯示。
由於顯示設備通常以 MMIO(Memory-Mapped I/O) 方式把畫面緩衝區 (frame buffer) 暴露給 CPU,對 CPU 而言更新畫面只是向某個地址寫入記憶體而已。因此,如果這段位址被註冊為一般的 guest RAM,guest 的寫入不會觸發 VM-exit,我們的 exit handler 就無法直接捕捉這類事件。要想在 host 端觀察到 framebuffer 的變更,以下提供簡單的觀察方法:
1.把那段地址當作 MMIO(不註冊為 RAM)或用 EPT 寫保護來強制 VM-exit
如果不把那段 GPA 註冊到 KVM 做為 RAM 使用,任何訪問會被視為 MMIO,讓 KVM 產生 KVM_EXIT_MMIO 的 VM-exit。或者我們可透過把頁的寫入權限清除(即 read-only),讓 Guest 寫入時觸發 EPT violation 並產生 VM-exit 讓 Host 可以即時處理。
但是頻繁的像素更新會造成大量的 VM-exit 這將造成大量的性能損耗。
2.在 Host 建立 timer 定期輪詢並刷新 guest frame buffer
把 framebuffer 當作正常的 guest RAM 映射回 host,host 以固定時間間隔 (如 10–50 ms) 取樣該記憶體區塊即可,這種做法簡單甚至可天然的透過 DMA 去做頁面處理。
由於 KVM 在建立 memory slot 時支援 dirty logging,因此我們可把方法 2 進一步改成差量更新,每次刷新前只需向 KVM 確認哪些頁被 Guest 修改過,讓 Host 的任務從刷新整個畫面變成刷新被修改頁的局部更新。
在先前的實作中,我們以 epoll 為核心建立了 host I/O 模組,我們可以簡單地將 timerfd 加入到 host I/O 模組中,並將畫面更新的邏輯掛接到處理方法中。這樣就可以在已有的 I/O thread 中定期處理畫面更新。
struct screen {
struct host_io *io; // epoll 封裝的設備管理結構
struct host_io_handle *timer_handle; // 用 timer 定期觸發
struct device_timer timer; // timer 結構體紀錄包括 fd 等信息
uint16_t *cells; // frame buffer
uint64_t *dirty_bitmap; // 用來取 dirty log
size_t dirty_bitmap_words;
size_t cell_count;
size_t columns;
size_t rows;
size_t page_start;
size_t page_end;
uint32_t slot_id; // 監控的 frame buffer 對應的 memory slot 編號
int vm_fd;
int refresh_interval_ms; // 螢幕刷新周期
};
int screen_init(struct screen *screen,
struct host_io *io,
int refresh_interval_ms)
{
if (!screen || !io || refresh_interval_ms <= 0) {
errno = EINVAL;
return -1;
}
memset(screen, 0, sizeof(*screen));
screen->io = io;
screen->refresh_interval_ms = refresh_interval_ms;
if (device_timer_init_ms(&screen->timer, refresh_interval_ms) < 0) // 建立 tiemrfd
goto fail;
screen->timer_handle = host_io_register(io, screen->timer.fd,
EPOLLIN | EPOLLERR | EPOLLHUP,
screen_on_timer, screen); // 註冊到 host io
if (!screen->timer_handle) {
int saved = errno;
device_timer_destroy(&screen->timer);
screen->io = NULL;
screen->refresh_interval_ms = 0;
errno = saved;
return -1;
}
return 0;
fail:
screen->io = NULL;
screen->refresh_interval_ms = 0;
screen->timer_handle = NULL;
return -1;
}
這樣就完成透過 tiemrfd 定期觸發螢幕刷新事件。
static void screen_on_timer(int fd, uint32_t events, void *opaque)
{
struct screen *screen = opaque;
if (!screen)
return;
if ((events & (EPOLLERR | EPOLLHUP)) != 0) {
perror("screen timerfd event");
return;
}
if ((events & EPOLLIN) == 0)
return;
uint64_t expirations = 0;
for (;;) {
ssize_t n = read(fd, &expirations, sizeof(expirations));
if (n == (ssize_t)sizeof(expirations))
break;
if (n < 0 && errno == EINTR)
continue;
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
return;
perror("screen timerfd read");
return;
}
if (expirations == 0 || !screen->cells || screen->cell_count == 0)
return;
screen_refresh(screen);
}
這個處理函數只做兩件事,用 read 把緩衝區清空,同時為了避免緩衝被打斷,用 for (;;) 刷新直到確定刷完為止。處理完後就謮轉發到 screen_refresh 做真正的處理邏輯。
void screen_refresh(struct screen *screen)
{
if (!screen || !screen->cells || screen->columns == 0 || screen->rows == 0)
return;
if (screen->cell_count == 0)
return;
if (screen->use_dirty_log)
screen_update_dirty_bitmap(screen);
screen_render_full(screen);
}
這裡就是真的刷新邏輯。